"""
task script parser

## step message
## @local @server @target+ubuntu:18.04
## GET /path/to/remote /path/to/local
## PUT /path/to/local /path/to/remote
## NOTIFY message
shell script
## EXPORT NAME_1,NAME_2
"""
import typing
import re
from textwrap import shorten


RE_TARGET_NAME = re.compile(r'[a-zA-Z0-9][a-zA-Z0-9_\-\.]*')
RE_EXPORT_NAME = re.compile(r'[a-zA-Z0-9\-_]+')


class TargetType:
    LOCAL = 'LOCAL'
    SERVER = 'SERVER'
    DOCKER = 'DOCKER'


TARGET_NAME_LOCAL = 'local'


class TaskTarget:
    def __init__(self, type: str, name: str, image: str = None):
        self.type = type
        self.name = name
        self.image = image

    def __eq__(self, other):
        return (
            isinstance(other, TaskTarget)
            and self.type == other.type
            and self.name == other.name
            and self.image == other.image
        )

    def __hash__(self):
        return hash((TaskTarget, self.type, self.name, self.image))

    def __str__(self):
        if self.type == TargetType.DOCKER:
            return f'@{self.name}+{self.image}'
        else:
            return f'@{self.name}'

    def __repr__(self):
        return f'<{type(self).__name__} {str(self)}>'


class TaskCommand:
    LOG = 'LOG'
    GET = 'GET'
    PUT = 'PUT'
    RUN = 'RUN'
    NOTIFY = 'NOTIFY'
    EXPORT = 'EXPORT'


class TaskStep:
    def __init__(
        self,
        lineno: int,
        source: str,
        command: str,
        targets: typing.List[TaskTarget],
        params: typing.Dict[str, typing.Any],
    ):
        self.lineno = lineno
        self.source = source
        self.command = command
        self.targets = targets
        self.params = params

    def __repr__(self):
        type_name = type(self).__name__
        targets = ''.join(str(x) for x in self.targets)
        params = {k: shorten(str(v), 40) for k, v in self.params.items()}
        return f'<{type_name} {targets} {self.command} {params}>'

    def __eq__(self, other):
        return (
            isinstance(other, TaskStep)
            and self.lineno == other.lineno
            and self.source == other.source
            and self.command == other.command
            and self.targets == other.targets
            and self.params == other.params
        )

    def __hash__(self):
        return hash((
            TaskStep, self.lineno, self.source,
            self.command, self.targets, self.params,
        ))


class TaskParser:

    def __init__(self):
        self.targets = [TaskTarget(type=TargetType.LOCAL, name=TARGET_NAME_LOCAL)]
        self.buffers: typing.List[typing.Tuple(int, str)] = []  # lineno, content
        self.steps: typing.List[TaskStep] = []

    def _flush_buffers(self):
        if not self.buffers:
            return
        source = ''.join(x[1] for x in self.buffers)
        content = source.strip()
        if content:
            lineno, __ = self.buffers[0]
            self.steps.append(TaskStep(
                lineno=lineno,
                source=source,
                command=TaskCommand.RUN,
                targets=self.targets,
                params=dict(script=content),
            ))
        self.buffers = []

    def _check_target_name(self, name):
        if not RE_TARGET_NAME.fullmatch(name):
            raise ValueError(f'invalid target name {name}')

    def _handle_targets(self, content):
        if not content:
            raise ValueError('empty targets')
        targets = []
        for part in content.split():
            items = part.lstrip('@').split('+', maxsplit=1)
            if len(items) == 2:
                name, image = items
                self._check_target_name(name)
                target = TaskTarget(type=TargetType.DOCKER, name=name, image=image)
            else:
                if items[0] == TARGET_NAME_LOCAL:
                    target = TaskTarget(type=TargetType.LOCAL, name=TARGET_NAME_LOCAL)
                else:
                    name = items[0]
                    self._check_target_name(name)
                    target = TaskTarget(type=TargetType.SERVER, name=name)
            targets.append(target)
        self.targets = targets

    @staticmethod
    def _strip_command(content):
        parts = content.split(maxsplit=1)
        return parts[1] if len(parts) > 1 else ''

    def _parse_notify(self, content):
        return dict(message=self._strip_command(content))

    def _parse_export(self, content):
        content = self._strip_command(content)
        if not content:
            raise ValueError('invalid EXPORT step')
        names = []
        for name in content.replace(',', ' ').split():
            if not RE_EXPORT_NAME.match(name):
                raise ValueError(f'invalid export name {name!r}')
            names.append(name)
        return dict(names=names)

    def _parse_get(self, content):
        parts = self._strip_command(content).split(maxsplit=2)
        if len(parts) != 2:
            raise ValueError('invalid GET step')
        remote_path, local_path = parts
        return dict(remote_path=remote_path, local_path=local_path)

    def _parse_put(self, content):
        parts = self._strip_command(content).split(maxsplit=2)
        if len(parts) != 2:
            raise ValueError('invalid PUT step')
        local_path, remote_path = parts
        return dict(remote_path=remote_path, local_path=local_path)

    def _parse_run(self, content):
        return dict(script=content)

    def _parse_log(self, content):
        return dict(message=content)

    def _merge_export_steps(self, steps: typing.List[TaskStep]):
        prev_step = None
        ret_steps = []
        for step in steps:
            if step.command != TaskCommand.EXPORT:
                ret_steps.append(step)
                prev_step = step
                continue
            if prev_step is None or prev_step.command != TaskCommand.RUN:
                raise ValueError(f'EXPORT must following RUN step: {step}')
            prev_step.params.update(exports=step.params['names'])
            prev_step.source += step.source
        return ret_steps

    def _parse(self, text):
        for lineno, line in enumerate(text.splitlines(keepends=True), 1):
            if not line.startswith('##'):
                self.buffers.append((lineno, line))
                continue
            self._flush_buffers()
            line_content = line[2:].strip()
            command = line_content.split(maxsplit=1)[0]
            try:
                if command == TaskCommand.GET:
                    params = self._parse_get(line_content)
                elif command == TaskCommand.PUT:
                    params = self._parse_put(line_content)
                elif command == TaskCommand.NOTIFY:
                    params = self._parse_notify(line_content)
                elif command == TaskCommand.EXPORT:
                    params = self._parse_export(line_content)
                else:
                    if command.startswith('@'):
                        self._handle_targets(line_content)
                    command = TaskCommand.LOG
                    params = self._parse_log(line_content)
            except ValueError as ex:
                ex.args += (f'lineno={lineno}',)
                raise
            self.steps.append(TaskStep(
                lineno=lineno,
                source=line,
                targets=self.targets,
                command=command,
                params=params,
            ))
        self._flush_buffers()
        self.steps = self._merge_export_steps(self.steps)
        return self.steps

    @classmethod
    def parse(cls, script: str = None, *, script_file: str = None) -> typing.List[TaskStep]:
        if script_file:
            script = cls._read_file(script_file)
        if not script:
            raise ValueError('not provided script or script_file')
        parser = cls()
        return parser._parse(script)

    @staticmethod
    def _read_file(filepath: str):
        with open(filepath) as f:
            return f.read()
